Skip to content

feat(destroy): graceful SIGINT handling — release lock + preserve state on interrupt - #826

Merged
go-to-k merged 3 commits into
mainfrom
fix/816-destroy-graceful-sigint
Jun 13, 2026
Merged

feat(destroy): graceful SIGINT handling — release lock + preserve state on interrupt#826
go-to-k merged 3 commits into
mainfrom
fix/816-destroy-graceful-sigint

Conversation

@go-to-k

@go-to-k go-to-k commented Jun 13, 2026

Copy link
Copy Markdown
Owner

Summary

cdkd destroy had no SIGINT handler. A Ctrl-C killed the process mid-destroy: the stack lock was left behind (TTL 30m) and the finally cleanup never ran. This adds graceful SIGINT (Terraform parity), building on #804's incremental destroy persistence.

Behavior

  • First Ctrl-C: stop scheduling new deletes (the reverse-DAG level loop breaks at the level boundary; per-resource defense-in-depth gate), let in-flight provider deletes finish (await Promise.all), then fall through to finally which flushes the incremental save-chain (destroy: interrupted/partially-failed destroy replays Custom Resource delete against an already-deleted backing Lambda — 10-minute stall on re-run #804) + releases the lock. State is preserved on interrupt (preserveState = errorCount > 0 || interrupted — no deleteState even at 0 errors, since resources remain), so a re-run resumes cleanly with no replay and no 30m lock wait.
  • Second Ctrl-C: force-quit — best-effort un-awaited releaseLock + always prints Force-quit: stack lock may not be released ... run: cdkd force-unlock <stack> to stderr, then process.exit(130).
  • New DestroyRunnerResult.interrupted; destroy.ts + state.ts break their multi-stack loops on the first interrupted stack and exit non-zero.
  • Per-call handler registered after lock acquisition, removed in finally (no leak across stacks / nested-stack recursion). process.setMaxListeners bumped (Math.max) so deep nesting + per-provider SIGINT handlers don't trip a spurious MaxListenersWarning.

Test plan

  • Unit (tests/unit/cli/destroy-runner-sigint.test.ts, 5): first Ctrl-C finishes in-flight + schedules no new + preserves trimmed state + releases lock + marks interrupted; level-boundary gate (diamond DAG); second Ctrl-C → best-effort release + recovery message + exit(130); normal completion → not interrupted + listener removed; removeListener in finally. Full suite 5740 tests pass.
  • Real-AWS (microservices broad integ): deploy + destroy clean — 19 deleted, 0 errors, 0 orphans — confirms the SIGINT handler leaves the happy-path destroy unchanged.

Independent review

Code review of the implementation + a focused re-review of the blocker fix-back: both clean, no blockers. The original review's BLOCKER (2nd-Ctrl-C stranded the lock), MaxListeners minor, and state.ts break-placement nit were all addressed and verified.

Deferred / related

The IGW/NAT delete-ordering gap (the other half of the original #804 incident) shipped separately as #823 (#817).

Closes #816

go-to-k added 3 commits June 13, 2026 15:31
…n first Ctrl-C)

Closes #816 (deferred "optional fix 3" from #804 / PR #814).

Before this change `cdkd destroy` / `cdkd state destroy` had no SIGINT
handler, so a first Ctrl-C killed the process mid-destroy: the `finally`
that releases the stack lock never ran (lock stranded for its full TTL),
and any in-flight provider delete was severed abruptly.

The destroy runner now registers a per-call SIGINT handler (Terraform
parity):

- First Ctrl-C sets a `draining` flag. The reverse-DAG delete loop checks
  it before scheduling each subsequent LEVEL (and, defense-in-depth,
  before dispatching each resource), so no new delete starts; the deletes
  already in flight in the current level are awaited to completion (not
  cancelled). Control then falls through to the existing `finally`, which
  flushes the incremental save-chain from #804 (preserved state lists only
  resources that still exist), stops the live renderer, and releases the
  lock. The destroy exits non-zero.
- Second Ctrl-C bypasses graceful shutdown (process.exit(130)).

On a graceful interrupt the runner PRESERVES state (does not deleteState
even though errorCount is 0, because resources remain) and surfaces a new
`DestroyRunnerResult.interrupted` flag; both destroy.ts and state.ts stop
their multi-stack loop on the first interrupted stack and throw
PartialFailureError (exit 2).

The handler reads/writes only its own call's closure state and is removed
via process.removeListener in the `finally`, so no listener leaks. Nested-
stack recursion registers one handler per level; Node delivers SIGINT to
every listener, so the first Ctrl-C drains the parent and every in-flight
child.

Tests: 5 unit tests in destroy-runner-sigint.test.ts (the handler is
captured by spying on process.on('SIGINT', ...) and invoked directly — no
real OS signal is sent). Happy-path destroy is unchanged.

Docs: destroy-interruption subsection in docs/state-management.md +
stale-lock note in docs/troubleshooting.md + changelog entry.
Fix one blocker plus two smaller findings from the independent review of
the #816 graceful-SIGINT implementation.

BLOCKER: a second Ctrl-C called process.exit(130) synchronously, bypassing
the finally that releases the stack lock — re-introducing the 30m-stranded-
lock bug on the force-quit path. The second-Ctrl-C handler now fires a
best-effort un-awaited releaseLock() AND always prints the exact recovery
command to stderr ("Force-quit: stack lock may not be released. If the next
run reports a lock, run: cdkd force-unlock <stackName>") before exiting, so
a stranded lock is always recoverable deterministically. Updated the unit
test to assert both the recovery message (with the real stack name) and the
best-effort release attempt. Documented the final force-quit semantics in
docs/troubleshooting.md.

Minor: deep nesting + high concurrency register many process SIGINT
listeners (one per nested level, plus per-provider handlers) and can exceed
Node's default 10-listener cap, emitting a misleading
MaxListenersExceededWarning. Raise the cap to 100 (via Math.max so recursion
never lowers it) with a comment explaining the headroom — leaves the warning
active above 100 so a real leak is still surfaced.

Nit: state.ts only had an inner per-region-loop break on interrupt and
relied on re-entry to stop the outer stack loop. Added an explicit
outer-loop guard mirroring destroy.ts's stack-loop break.
@go-to-k
go-to-k merged commit 06d1c8b into main Jun 13, 2026
5 checks passed
@go-to-k
go-to-k deleted the fix/816-destroy-graceful-sigint branch June 13, 2026 06:36
github-actions Bot pushed a commit that referenced this pull request Jun 13, 2026
# [0.221.0](v0.220.5...v0.221.0) (2026-06-13)

### Features

* **destroy:** graceful SIGINT handling — release lock + preserve state on interrupt ([#826](#826)) ([06d1c8b](06d1c8b))
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 0.221.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

destroy: graceful SIGINT handling (release lock + flush state on first Ctrl-C, force-quit on second)

1 participant